Repository navigation
fix(metadata)!: unregisterPackage keeps the rows that name the package by the _packageId stamp alone; detector for 17.0.0–18.3.0 stamped environment rows (#32985) - #33416
Conversation
… by the _packageId stamp alone (#32985) The boot of @objectstack/metadata 17.0.0-18.3.0 on a host declaring its package saved the host's stamp into the environment's own DatabaseLoader rows. unregisterPackage now keeps and reports an item whose stored row has an empty package_id column and names the package by _packageId alone, unless forced; bindReadRowPackage binds no column from such a row; the operator migration leaves it stamp-only; detectStampOnlyRows reports each such row with its history evidence. Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw Co-authored-by: Claude <noreply@anthropic.com>
…, the migration's stamp-only reason and the detector (#32985) Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw Co-authored-by: Claude <noreply@anthropic.com>
…rough unregisterPackageWithReport; triage the pins the guard moves (#32985) Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw Co-authored-by: Claude <noreply@anthropic.com>
…e host by its stamp alone (#32985) Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw Co-authored-by: Claude <noreply@anthropic.com>
…s not implement (#32985) Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 16445492d6ae0c92a7525a7ecf6872bf20aac02e && git checkout 16445492d6ae0c92a7525a7ecf6872bf20aac02e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bd439f17fa575f38d810c7d58e6873918924ed9b 46ee0cd402b1ca1cbbc6fbaad95cafcad2eda2fa && git checkout -B drift-repro bd439f17fa575f38d810c7d58e6873918924ed9b && git merge --no-ff 46ee0cd402b1ca1cbbc6fbaad95cafcad2eda2fa
node scripts/docs-audit/affected-docs.mjs --json bd439f17fa575f38d810c7d58e6873918924ed9b
|
|
domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw |
|
Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head 7617d86 · 2026-11-14T19:46:59Z review#3. |
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe maintainer's guard ruling picks fail-closed: an environment row is never removed or bound to the host by its stamp, at the cost of keeping some of the host's own rows. This implementation follows that to the letter at all three doors. It answers what it kept rather than staying silent, keeps the escape hatch explicit ( Implemented-by: VERDICT: FAIL M1 is text only (changeset arm, |
…BREAKING note Review M1 on the stamp-only rows guard: unregisterPackage keeps items it used to remove until forced, and the bind and the migration leave rows unbound, so the changeset declares Clause-② yes (narrowing), the `!` subject, a BREAKING paragraph with the remedy, and the adr-0087 not-required marker. Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw Co-authored-by: Claude <noreply@anthropic.com>
|
Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head 46ee0cd · 2026-11-14T19:51:29Z review#3 (delta). |
Contract reviewServed-tier: Delta over my FAIL at ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentUnchanged: fail-closed at all three doors, as the maintainer's ruling asks, with an explicit Implemented-by: VERDICT: PASS |
|
domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
|
Requested by Elon · project thread
Refs #32985
The card stays open (seat ruling 2026-11-14T19:48Z): the follow-up PR keeps column-bound rows an 18.4.0 boot bound unless history shows them stamped since create (kept on undetermined or with no history table), and notes that a future uninstall UI must use
unregisterPackageWithReportand surfacekept(IMetadataService.unregisterPackagehas no force or result).Clause-②: yes (narrowing) — narrowed:
unregisterPackage(P)keeps, until{ force: true }, items it used to remove (every item stored in a row with an emptypackage_idwhose body names P by_packageIdalone, including everyDatabaseLoader-storedobject/view/app/pageof P); the boot bind andmigrateDatabaseLoaderRowPackagesleave such rows unbound. Widened: additive in-process surface (unregisterPackageWithReport,{ force },MetadataLoader.stampOnlyRows,detectStampOnlyRows, the'stamp-only'left reason).Metadata-protocol: none
BREAKING (review#3 M1, 6883260484): a caller of
MetadataManager.unregisterPackageover aDatabaseLoadernow keeps and reports items whose stored rows name the package by the_packageIdstamp alone, everyobject/view/app/pageitem of the package among them. RundetectStampOnlyRows, then pass{ force: true }(or useunregisterPackageWithReport) to remove the package's own items as before. The changeset carries the adr-0087 not-required (no-migration-prescription) marker. The any-source guard (wider than the ruling'ssource='database') was accepted by review because it only errs toward keeping.维护者速读
unregisterPackage遇到「package_id列为空、正文只靠_packageId印记指向该包」的存储行时,默认保留该条目并报告({ force: true }才删);启动时的列绑定与运维迁移不再凭这种印记绑定package_id;新增只读检测器detectStampOnlyRows,按每行自己的历史判定印记来源;changeset 附升级说明。force;无数据迁移、无新写入,回滚即 revert 本 PR。packages/metadata/src/metadata-manager.ts的unregisterPackageWithReport(先全量检查、后删除);packages/metadata/src/loaders/database-loader.ts的bindReadRowPackage新跳过与stampOnlyRows;packages/metadata/src/migrations/detect-stamp-only-rows.ts的历史链判定。pnpm --filter @objectstack/metadata exec vitest run src/loaders/database-loader.test.ts -t 32985,预期所有 metadata: measure the 18.3.0 environment rows that carry the host's stamp in their stored body, and decide whether a stamp-removal migration is owed (follow-up of #32791) #32985 用例通过(18.3.0 形态的行全部保留、force后删除)。What changes
The binding ruling is the maintainer decision of 2026-11-14 on #32985 (guard + detector + upgrade note; no ownership-changing migration now), as the seat recorded it on the card. Every
@objectstack/metadatafrom 17.0.0 through 18.3.0, on a host whoseMetadataPlugindeclares itspackageId, primed eagerly with noartifactSource, with aDatabaseLoader, saved each package-less row its boot pass read back stamped with the host (_packageId,_provenance). From the row alone such an environment row cannot be told from a row of the host's own item; the #32986 mark does not reach it, because its stored body already names the host.1. The guard (red line).
unregisterPackage(P)(and the newunregisterPackageWithReport(P)) keeps an item, registry entry and rows, when any loader holds a row of it whosepackage_idcolumn is empty and whose stored body names P by_packageIdalone, neitherpackageIdnor the legacypackagenaming any package (stampOnlyPackageOf, newsrc/stamp-only-row.ts;DatabaseLoader.stampOnlyRows, new optionalMetadataLoader.stampOnlyRows). Every row is checked before the first removal, so a failed read throws and removes nothing. Each kept item is reported in the result (kept,reason: 'stamp-only-row', each row's loader, id andsource) and in onewarnnaming the detector and{ force: true };forceremoves them as before. The check reads storage at removal time rather than marking registry objects, so no copy a later writer registers can drop it (the hole #33003 had to close for the #32986 mark).source = 'database': the check takes every writer's row. Measured on the 18.3.0-built fixture, the boot also stamped another writer's package-less row (agent/api_agent,source = 'api'), andmaindeleted it onunregisterPackage(host). Under 64ce235's reading a body stamp in such a row binds nothing (it is the environment's for every column reader), so keeping it only ever keeps an environment row.unregisterPackageWithReportis a method of its own because the spec'sIMetadataService.unregisterPackageanswers void and a class method cannot answer more under that signature; the published contract is not widened here.2.
bindReadRowPackage(the boot's column-only bind): it can skip stamp-only rows, and does. FROM binding the emptypackage_idcolumn of a legacy row whose body names its package by_packageIdalone TO leaving it. Pinned both ways: such a row (the host's stamp, or another package's) is not bound and nothing is written; a legacy row naming its package by its ownpackageIdis still bound; and the cost to a genuine pre-#32246 legacy row is pinned by a CONTROL — a row the host's file item seeded is bound on the same boot by that item's own save (the save path's column-only binding, unchanged). What is left unbound is a legacy row no shipped code rewrites; reads of it are unchanged (the loader reads its package off the body).3.
migrateDatabaseLoaderRowPackagesleaves the same rows, new reason'stamp-only'.4. Read-only detector:
detectStampOnlyRows(store, { packageId? })in@objectstack/metadata/migrations, next to the migration it pairs with and in the shape of the read-onlyscrubHistoryDrainedKeyscount — chosen over anos migrate meta --stored-style CLI because it is the smallest thing an operator can run against a driver today, touches no package outside@objectstack/metadata, and a CLI can wrap it later. It takesfindonly and reports every row whose body names a package by_packageIdalone with its column empty or naming that package (an 18.4.0 boot bound by-package rows to the host), each with a verdict from its own history, walked back from the row'schecksumthroughprevious_checksum(so another holder's history of the name is never read as its own):stamp-added(a version naming no package, then one adding the stamp;onlyStampAddedmarks the boot's signature),stamped-since-create, orundetermined.5. Upgrade note in the changeset: affected releases 17.0.0–18.3.0, the composition, the symptoms (rows read as host-owned, phantom 409 holders, deletion on uninstall before this change, the 18.4.0 column bind), the detector, and the manual recipe for a
stamp-addedrow. It makes no claim about first-party reach (the cloud product'sMetadataPluginconfiguration is owed a separate check).6. Fold-in (review nit N1 of merged PR #33299):
keyedEntryBodies's doc comment now says the order handed on is the keyed entries first, then the unkeyed bodies, FROMloadMany's row order. Text only.unregisterPackage(P)over an item a loader stores in a row with an emptypackage_idwhose body names P by_packageIdalone, anysourcekept, onewarn); nothing removed if a check failsunregisterPackage(P, { force: true })unregisterPackage(P)over an item whose body names P by its ownpackageId/package, or whose column is bound_packageIdalonemigrateDatabaseLoaderRowPackagesover such a row'stamp-only'Tests
describe('[#32985] …')indatabase-loader.test.ts(rows seeded as 18.3.0 left them, by-package and other types): the boot binds no column and writes nothing;unregisterPackageWithReport(host)keeps every item and row and reports each, onewarn; another writer's stamped row kept the same; the contract door answers nothing and keeps them,forceremoves them; CONTROL rows naming the host by their ownpackageIdremoved withoutforce; CONTROL another package's removal moves nothing; a failing check throws before any removal.packageId; the file-seeded legacy row CONTROL above.detect-stamp-only-rows.test.ts(new, 8 cases): each verdict, the 18.4.0-bound column,onlyStampAddedfalse, an unprovisioned history table (unavailable) vs any other read failure (thrown), the per-row checksum chain beside another holder's history, the rows never reported,packageIdnarrowing, andfind-only access.bind-database-loader-row-packages.test.tsfixtures_packageId: AtopackageId: A(26 lines), and the two C3bindReadRowPackagecases inmetadata-manager-match-endpoint.test.ts; the migration's first case now also pinsstamp-onlyfor a flat and a wrapped stamp-only body and binding for a stamp beside the item's ownpackageId.force; the metadata: unregisterPackage and unregisterAt remove a slotted type's item by (package, name), match an artifact-door item's _packageId, and name the package on the deleted event (ADR-0150 D4) #32198 CONTROL for a type outside the set (a_packageId-only item's row, whichDatabaseLoadernever column-binds) is now kept-and-reported, then removed withforce.forcedoes not lift): the metadata: a rewrite that keeps the host's stamp turns an environment row into the host's own, and unregistering the host deletes it again (gates the release shipping #32198) #33003 CONTROLs whose host rows carry the stamp alone (database-loader.test.ts, six writers plus three single cases) and the objectql late-bridge CONTROL (plugin-late-bridge-environment-row.test.ts); the metadata: the artifact door's memory loader keeps serving a non-slotted type's item after it is unregistered #32353 door-removal CONTROL.Acceptance notes
package_idcolumn of each stamped by-package row to the host at first boot. Such a row now names the host by its column, so the ruling's criterion (no package column) does not hold andunregisterPackage(host)still removes it. The detector reports it (columnset, verdict from history). Whether the deletion door should also consult history for column-bound rows is a decision, raised in the report, not taken here.action/helperis still refused409 ambiguous-in-environmentnaming the host as a holder, until the row is repaired by hand (recipe in the changeset).DatabaseLoaderwrites no column for a type not stored per(package, name), so every row it stores for a package's item of such a type names the package by the stamp alone and is now kept untilforce.unregisterPackagehas no first-party production caller (its CHANGELOG records 0).flow/pkg_flowin the fixture): the column owns it.MetadataManager.registerof a by-package item read from a stamp-only row still binds that row's column through the save path's column-only binding when it writes the row (unchanged; the boot no longer does).Verification
Final head
7617d86917unless a line says otherwise. Basefca5f89cce.metadata: typecheck + full suite (head 0ca9978; later commits touch tests only, re-run below)
tsc --noEmit --listFilesincludes all four edited test files. Detector file re-run at the final head:Tests 8 passed (8).dependents that use the moved doors (head c90052e)
pnpm --filter @objectstack/objectql --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/mcp --filter @objectstack/service-datasource test, after building their closures:Chosen by
git grepfor every package outsidemetadatathat callsunregisterPackage,bindReadRowPackage,migrateDatabaseLoaderRowPackagesorDatabaseLoaderRowLeftReason, or whose tests build aDatabaseLoader. The rest of the 87-package affected set (select-shard-packages.sh, pull_request mode) isTest Core's, in CI.replay over a real 18.3.0-built SQLite database (the card's measurement fixture, 37 rows)
Same probe script as the measurement, pointed at this branch's build:
Reads identical to
mainon every probed name; the one write-door difference is the column the boot no longer binds (action/helper's stamped row stays column-empty). Detector over the same file,packageId: com.host.app: 37 scanned, 34 reported — 32stamp-added(allonlyStampAdded, history1 then 2), 2undetermined(rows the fixture inserted raw, without a create in history); the database file's hash is unchanged. Over the never-stamped control: 37 scanned, 0 reported.ablations (head c90052e, restored; git diff HEAD empty afterwards)
Through
scripts/ablation-replace.mjs(anchor hit x1 then x0, replacement landed), each followed by the three pinning files:gates
The 66 families
node scripts/pm/dispatch-gates.mjs --commandsderives for this diff were each run at head7617d86917, with each exit code captured before any pipe: 66 of 66 exited 0. They includecheck:where-matcher,check:engine-double-contract,check:nul-bytesandcheck:dual-build-cjs-loads. On a first run, before the dependents' dists were built,check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET); on the run cited here it exited 0.eslint --no-inline-configover the 13 changed TypeScript files: exit 0. CI runs the rest: repo-widepnpm lint, the artifact-roster and wide-population families, and the fullTest Corematrix.Generated by Claude Code